import {
	Matrix4,
	Quaternion,
	Vector2,
	Vector3,
	MathUtils,
	Ray,
	Group,
} from 'three';
import { DRAG, ZOOM, EnvironmentControls, NONE } from './EnvironmentControls.js';
import { makeRotateAroundPoint, mouseToCoords, setRaycasterFromCamera } from './utils.js';
import { Ellipsoid } from '../math/Ellipsoid.js';
import { WGS84_ELLIPSOID } from '../math/GeoConstants.js';

const _invMatrix = /* @__PURE__ */ new Matrix4();
const _rotMatrix = /* @__PURE__ */ new Matrix4();
const _pos = /* @__PURE__ */ new Vector3();
const _vec = /* @__PURE__ */ new Vector3();
const _center = /* @__PURE__ */ new Vector3();
const _forward = /* @__PURE__ */ new Vector3();
const _targetRight = /* @__PURE__ */ new Vector3();
const _globalUp = /* @__PURE__ */ new Vector3();
const _quaternion = /* @__PURE__ */ new Quaternion();
const _zoomPointUp = /* @__PURE__ */ new Vector3();
const _toCenter = /* @__PURE__ */ new Vector3();
const _ray = /* @__PURE__ */ new Ray();
const _ellipsoid = /* @__PURE__ */ new Ellipsoid();
const _pointer = /* @__PURE__ */ new Vector2();
const _latLon = {};

// hand picked minimum elevation to tune far plane near surface
const MIN_ELEVATION = 2550;
export class GlobeControls extends EnvironmentControls {

	get tilesGroup() {

		console.warn( 'GlobeControls: "tilesGroup" has been deprecated. Use "ellipsoidGroup", instead.' );
		return this.ellipsoidFrame;

	}

	get ellipsoidFrame() {

		return this.ellipsoidGroup.matrixWorld;

	}

	get ellipsoidFrameInverse() {

		const { ellipsoidGroup, ellipsoidFrame, _ellipsoidFrameInverse } = this;
		return ellipsoidGroup.matrixWorldInverse ?
			ellipsoidGroup.matrixWorldInverse :
			_ellipsoidFrameInverse.copy( ellipsoidFrame ).invert();

	}

	constructor( scene = null, camera = null, domElement = null, tilesRenderer = null ) {

		// store which mode the drag stats are in
		super( scene, camera, domElement );

		this.isGlobeControls = true;

		this._dragMode = 0;
		this._rotationMode = 0;
		this.maxZoom = 0.01;
		this.nearMargin = 0.25;
		this.farMargin = 0;
		this.useFallbackPlane = false;
		this.autoAdjustCameraRotation = false;

		this.globeInertia = new Quaternion();
		this.globeInertiaFactor = 0;

		this.ellipsoid = WGS84_ELLIPSOID.clone();
		this.ellipsoidGroup = new Group();
		this._ellipsoidFrameInverse = new Matrix4();

		if ( tilesRenderer !== null ) {

			this.setTilesRenderer( tilesRenderer );

		}

	}

	setTilesRenderer( tilesRenderer ) {

		super.setTilesRenderer( tilesRenderer );
		if ( tilesRenderer !== null ) {

			this.setEllipsoid( tilesRenderer.ellipsoid, tilesRenderer.group );

		}

	}

	setEllipsoid( ellipsoid, ellipsoidGroup ) {

		this.ellipsoid = ellipsoid || WGS84_ELLIPSOID.clone();
		this.ellipsoidGroup = ellipsoidGroup || new Group();

	}

	getPivotPoint( target ) {

		const { camera, ellipsoidFrame, ellipsoidFrameInverse, ellipsoid } = this;

		// get camera values
		_forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );

		// set a ray in the local ellipsoid frame
		_ray.origin.copy( camera.position );
		_ray.direction.copy( _forward );
		_ray.applyMatrix4( ellipsoidFrameInverse );

		// get the estimated closest point
		ellipsoid
			.closestPointToRayEstimate( _ray, _vec )
			.applyMatrix4( ellipsoidFrame );

		// use the closest point if no pivot was provided or it's closer
		if (
			super.getPivotPoint( target ) === null ||
			_pos.subVectors( target, _ray.origin ).dot( _ray.direction ) > _pos.subVectors( _vec, _ray.origin ).dot( _ray.direction )
		) {

			target.copy( _vec );

		}

		return target;

	}

	// get the vector to the center of the provided globe
	getVectorToCenter( target ) {

		const { ellipsoidFrame, camera } = this;
		return target
			.setFromMatrixPosition( ellipsoidFrame )
			.sub( camera.position );

	}

	// get the distance to the center of the globe
	getDistanceToCenter() {

		return this
			.getVectorToCenter( _vec )
			.length();

	}

	getUpDirection( point, target ) {

		// get the "up" direction based on the wgs84 ellipsoid
		const { ellipsoidFrame, ellipsoidFrameInverse, ellipsoid } = this;
		_vec.copy( point ).applyMatrix4( ellipsoidFrameInverse );

		ellipsoid.getPositionToNormal( _vec, target );
		target.transformDirection( ellipsoidFrame );

	}

	getCameraUpDirection( target ) {

		const { ellipsoidFrame, ellipsoidFrameInverse, ellipsoid, camera } = this;
		if ( camera.isOrthographicCamera ) {

			this._getVirtualOrthoCameraPosition( _vec );

			_vec.applyMatrix4( ellipsoidFrameInverse );

			ellipsoid.getPositionToNormal( _vec, target );
			target.transformDirection( ellipsoidFrame );

		} else {

			this.getUpDirection( camera.position, target );

		}

	}

	update( deltaTime = Math.min( this.clock.getDelta(), 64 / 1000 ) ) {

		if ( ! this.enabled || ! this.camera || deltaTime === 0 ) {

			return;

		}

		const { camera, pivotMesh } = this;

		// if we're outside the transition threshold then we toggle some reorientation behavior
		// when adjusting the up frame while moving the camera
		if ( this._isNearControls() ) {

			this.scaleZoomOrientationAtEdges = this.zoomDelta < 0;

		} else {

			if ( this.state !== NONE && this._dragMode !== 1 && this._rotationMode !== 1 ) {

				pivotMesh.visible = false;

			}
			this.scaleZoomOrientationAtEdges = false;

		}

		const adjustCameraRotation = this.needsUpdate || this._inertiaNeedsUpdate();

		// fire basic controls update
		super.update( deltaTime );

		// update the camera planes and the ortho camera position
		this.adjustCamera( camera );

		// align the camera up vector if the camera as updated
		if ( adjustCameraRotation && this._isNearControls() ) {

			this.getCameraUpDirection( _globalUp );
			this._alignCameraUp( _globalUp, 1 );

			this.getCameraUpDirection( _globalUp );
			this._clampRotation( _globalUp );

		}

	}


	// Updates the passed camera near and far clip planes to encapsulate the ellipsoid from the
	// current position in addition to adjusting the height.
	adjustCamera( camera ) {

		super.adjustCamera( camera );

		const { ellipsoidFrame, ellipsoidFrameInverse, ellipsoid, nearMargin, farMargin } = this;
		const maxRadius = Math.max( ...ellipsoid.radius );
		if ( camera.isPerspectiveCamera ) {

			// adjust the clip planes
			const distanceToCenter = _vec
				.setFromMatrixPosition( ellipsoidFrame )
				.sub( camera.position ).length();

			// update the projection matrix
			// interpolate from the 25% radius margin around the globe down to the surface
			// so we can avoid z fighting when near value is too far at a high altitude
			const margin = nearMargin * maxRadius;
			const alpha = MathUtils.clamp( ( distanceToCenter - maxRadius ) / margin, 0, 1 );
			const minNear = MathUtils.lerp( 1, 1000, alpha );
			camera.near = Math.max( minNear, distanceToCenter - maxRadius - margin );

			// update the far plane to the horizon distance
			_pos.copy( camera.position ).applyMatrix4( ellipsoidFrameInverse );
			ellipsoid.getPositionToCartographic( _pos, _latLon );

			// use a minimum elevation for computing the horizon distance to avoid the far clip
			// plane approaching zero or clipping mountains over the horizon in the distance as
			// the camera goes to or below sea level.
			const elevation = Math.max( ellipsoid.getPositionElevation( _pos ), MIN_ELEVATION );
			const horizonDistance = ellipsoid.calculateHorizonDistance( _latLon.lat, elevation );

			camera.far = horizonDistance + 0.1 + maxRadius * farMargin;
			camera.updateProjectionMatrix();

		} else {

			this._getVirtualOrthoCameraPosition( camera.position, camera );
			camera.updateMatrixWorld();

			_invMatrix.copy( camera.matrixWorld ).invert();
			_vec.setFromMatrixPosition( ellipsoidFrame ).applyMatrix4( _invMatrix );

			const distanceToCenter = - _vec.z;
			camera.near = distanceToCenter - maxRadius * ( 1 + nearMargin );
			camera.far = distanceToCenter + 0.1 + maxRadius * farMargin;

			// adjust the position of the ortho camera such that the near value is 0
			camera.position.addScaledVector( _forward, camera.near );
			camera.far -= camera.near;
			camera.near = 0;

			camera.updateProjectionMatrix();
			camera.updateMatrixWorld();

		}

	}

	// resets the "stuck" drag modes
	setState( ...args ) {

		super.setState( ...args );
		this._dragMode = 0;
		this._rotationMode = 0;

	}

	_updateInertia( deltaTime ) {

		super._updateInertia( deltaTime );

		const {
			globeInertia,
			enableDamping,
			dampingFactor,
			camera,
			cameraRadius,
			minDistance,
			inertiaTargetDistance,
			ellipsoidFrame,
		} = this;

		if ( ! this.enableDamping || this.inertiaStableFrames > 1 ) {

			this.globeInertiaFactor = 0;
			this.globeInertia.identity();
			return;

		}

		const factor = Math.pow( 2, - deltaTime / dampingFactor );
		const stableDistance = Math.max( camera.near, cameraRadius, minDistance, inertiaTargetDistance );
		const resolution = 2 * 1e3;
		const pixelWidth = 2 / resolution;
		const pixelThreshold = 0.25 * pixelWidth;

		_center.setFromMatrixPosition( ellipsoidFrame );

		if ( this.globeInertiaFactor !== 0 ) {

			// calculate two screen points at 1 pixel apart in our notional resolution so we can stop when the delta is ~ 1 pixel
			// projected into world space
			setRaycasterFromCamera( _ray, _vec.set( 0, 0, - 1 ), camera );
			_ray.applyMatrix4( camera.matrixWorldInverse );
			_ray.direction.normalize();
			_ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _vec );
			_vec.applyMatrix4( camera.matrixWorld );

			setRaycasterFromCamera( _ray, _pos.set( pixelThreshold, pixelThreshold, - 1 ), camera );
			_ray.applyMatrix4( camera.matrixWorldInverse );
			_ray.direction.normalize();
			_ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _pos );
			_pos.applyMatrix4( camera.matrixWorld );

			// get implied angle
			_vec.sub( _center ).normalize();
			_pos.sub( _center ).normalize();

			this.globeInertiaFactor *= factor;
			const threshold = _vec.angleTo( _pos ) / deltaTime;
			const globeAngle = 2 * Math.acos( globeInertia.w ) * this.globeInertiaFactor;
			if ( globeAngle < threshold || ! enableDamping ) {

				this.globeInertiaFactor = 0;
				globeInertia.identity();

			}

		}

		if ( this.globeInertiaFactor !== 0 ) {

			// ensure our w component is non-one if the xyz values are
			// non zero to ensure we can animate
			if (
				globeInertia.w === 1 && (
					globeInertia.x !== 0 ||
					globeInertia.y !== 0 ||
					globeInertia.z !== 0
				)
			) {

				globeInertia.w = Math.min( globeInertia.w, 1 - 1e-9 );

			}

			// construct the rotation matrix
			_center.setFromMatrixPosition( ellipsoidFrame );
			_quaternion.identity().slerp( globeInertia, this.globeInertiaFactor * deltaTime );
			makeRotateAroundPoint( _center, _quaternion, _rotMatrix );

			// apply the rotation
			camera.matrixWorld.premultiply( _rotMatrix );
			camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec );

		}

	}

	_inertiaNeedsUpdate() {

		return super._inertiaNeedsUpdate() || this.globeInertiaFactor !== 0;

	}

	_updatePosition( deltaTime ) {

		if ( this.state === DRAG ) {

			// save the drag mode state so we can update the pivot mesh visuals in "update"
			if ( this._dragMode === 0 ) {

				this._dragMode = this._isNearControls() ? 1 : - 1;

			}

			const {
				raycaster,
				camera,
				pivotPoint,
				pointerTracker,
				domElement,
				ellipsoidFrame,
				ellipsoidFrameInverse,
			} = this;

			// reuse cache variables
			const pivotDir = _pos;
			const newPivotDir = _targetRight;

			// get the pointer and ray
			pointerTracker.getCenterPoint( _pointer );
			mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer );
			setRaycasterFromCamera( raycaster, _pointer, camera );

			// transform to ellipsoid frame
			raycaster.ray.applyMatrix4( ellipsoidFrameInverse );

			// construct an ellipsoid that matches a sphere with the radius of the globe so
			// the drag position matches where the initial click was
			const pivotRadius = _vec.copy( pivotPoint ).applyMatrix4( ellipsoidFrameInverse ).length();
			_ellipsoid.radius.setScalar( pivotRadius );

			// if we drag off the sphere then end the operation and follow through on the inertia
			if ( ! _ellipsoid.intersectRay( raycaster.ray, _vec ) ) {

				this.resetState();
				this._updateInertia( deltaTime );
				return;

			}

			_vec.applyMatrix4( ellipsoidFrame );

			// get the point directions
			_center.setFromMatrixPosition( ellipsoidFrame );
			pivotDir.subVectors( pivotPoint, _center ).normalize();
			newPivotDir.subVectors( _vec, _center ).normalize();

			// construct the rotation
			_quaternion.setFromUnitVectors( newPivotDir, pivotDir );
			makeRotateAroundPoint( _center, _quaternion, _rotMatrix );

			// apply the rotation
			camera.matrixWorld.premultiply( _rotMatrix );
			camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec );

			if ( pointerTracker.getMoveDistance() / deltaTime < 2 * window.devicePixelRatio ) {

				this.inertiaStableFrames ++;

			} else {

				this.globeInertia.copy( _quaternion );
				this.globeInertiaFactor = 1 / deltaTime;
				this.inertiaStableFrames = 0;

			}

		}

	}

	// disable rotation once we're outside the control transition
	_updateRotation( ...args ) {

		if ( this._rotationMode === 1 || this._isNearControls() ) {

			this._rotationMode = 1;
			super._updateRotation( ...args );

		} else {

			this.pivotMesh.visible = false;
			this._rotationMode = - 1;

		}


	}

	_updateZoom() {

		const { zoomDelta, ellipsoid, zoomSpeed, zoomPoint, camera, maxZoom, state } = this;

		if ( state !== ZOOM && zoomDelta === 0 ) {

			return;

		}

		// reset momentum
		this.rotationInertia.set( 0, 0 );
		this.dragInertia.set( 0, 0, 0 );
		this.globeInertia.identity();
		this.globeInertiaFactor = 0;

		// used to scale the tilt transitions based on zoom intensity
		const deltaAlpha = MathUtils.clamp( MathUtils.mapLinear( Math.abs( zoomDelta ), 0, 20, 0, 1 ), 0, 1 );
		if ( this._isNearControls() || zoomDelta > 0 ) {

			this._updateZoomDirection();

			// When zooming try to tilt the camera towards the center of the planet to avoid the globe
			// spinning as you zoom out from the horizon
			if ( zoomDelta < 0 && ( this.zoomPointSet || this._updateZoomPoint() ) ) {

				// get the forward vector and vector toward the center of the ellipsoid
				_forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ).normalize();
				_toCenter.copy( this.up ).multiplyScalar( - 1 );

				// Calculate alpha values to use to scale the amount of tilt that occurs as the camera moves.
				// Scales based on mouse position near the horizon and current tilt.
				this.getUpDirection( zoomPoint, _zoomPointUp );
				const upAlpha = MathUtils.clamp( MathUtils.mapLinear( - _zoomPointUp.dot( _toCenter ), 1, 0.95, 0, 1 ), 0, 1 );
				const forwardAlpha = 1 - _forward.dot( _toCenter );
				const cameraAlpha = camera.isOrthographicCamera ? 0.05 : 1;
				const adjustedDeltaAlpha = MathUtils.clamp( deltaAlpha * 3, 0, 1 );

				// apply scale
				const alpha = Math.min( upAlpha * forwardAlpha * cameraAlpha * adjustedDeltaAlpha, 0.1 );
				_toCenter.lerpVectors( _forward, _toCenter, alpha ).normalize();

				// perform rotation
				_quaternion.setFromUnitVectors( _forward, _toCenter );
				makeRotateAroundPoint( zoomPoint, _quaternion, _rotMatrix );
				camera.matrixWorld.premultiply( _rotMatrix );
				camera.matrixWorld.decompose( camera.position, camera.quaternion, _toCenter );

				// update zoom direction
				this.zoomDirection.subVectors( zoomPoint, camera.position ).normalize();

			}

			super._updateZoom();

		} else if ( camera.isPerspectiveCamera ) {

			// orient the camera to focus on the earth during the zoom
			const transitionDistance = this._getPerspectiveTransitionDistance();
			const maxDistance = this._getMaxPerspectiveDistance();
			const distanceAlpha = MathUtils.mapLinear( this.getDistanceToCenter(), transitionDistance, maxDistance, 0, 1 );
			this._tiltTowardsCenter( MathUtils.lerp( 0, 0.4, distanceAlpha * deltaAlpha ) );
			this._alignCameraUpToNorth( MathUtils.lerp( 0, 0.2, distanceAlpha * deltaAlpha ) );

			// calculate zoom in a similar way to environment controls so
			// the zoom speeds are comparable
			const dist = this.getDistanceToCenter() - ellipsoid.radius.x;
			const scale = zoomDelta * dist * zoomSpeed * 0.0025;
			const clampedScale = Math.max( scale, Math.min( this.getDistanceToCenter() - maxDistance, 0 ) );

			// zoom out directly from the globe center
			this.getVectorToCenter( _vec ).normalize();
			this.camera.position.addScaledVector( _vec, clampedScale );
			this.camera.updateMatrixWorld();

			this.zoomDelta = 0;

		} else {

			const transitionZoom = this._getOrthographicTransitionZoom();
			const minZoom = this._getMinOrthographicZoom();
			const distanceAlpha = MathUtils.mapLinear( camera.zoom, transitionZoom, minZoom, 0, 1 );
			this._tiltTowardsCenter( MathUtils.lerp( 0, 0.4, distanceAlpha * deltaAlpha ) );
			this._alignCameraUpToNorth( MathUtils.lerp( 0, 0.2, distanceAlpha * deltaAlpha ) );

			const scale = this.zoomDelta;
			const normalizedDelta = Math.pow( 0.95, Math.abs( scale * 0.05 ) );
			const scaleFactor = scale > 0 ? 1 / Math.abs( normalizedDelta ) : normalizedDelta;

			const maxScaleFactor = minZoom / camera.zoom;
			const clampedScaleFactor = Math.max( scaleFactor * zoomSpeed, Math.min( maxScaleFactor, 1 ) );

			camera.zoom = Math.min( maxZoom, camera.zoom * clampedScaleFactor );
			camera.updateProjectionMatrix();

			this.zoomDelta = 0;
			this.zoomDirectionSet = false;

		}

	}

	// tilt the camera to align with north
	_alignCameraUpToNorth( alpha ) {

		const { ellipsoidFrame } = this;
		_globalUp.set( 0, 0, 1 ).transformDirection( ellipsoidFrame );
		this._alignCameraUp( _globalUp, alpha );

	}

	// tilt the camera to look at the center of the globe
	_tiltTowardsCenter( alpha ) {

		const {
			camera,
			ellipsoidFrame,
		} = this;

		_forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ).normalize();
		_vec.setFromMatrixPosition( ellipsoidFrame ).sub( camera.position ).normalize();
		_vec.lerp( _forward, 1 - alpha ).normalize();

		_quaternion.setFromUnitVectors( _forward, _vec );
		camera.quaternion.premultiply( _quaternion );
		camera.updateMatrixWorld();

	}

	// returns the perspective camera transition distance can move to based on globe size and fov
	_getPerspectiveTransitionDistance() {

		const { camera, ellipsoid } = this;
		if ( ! camera.isPerspectiveCamera ) {

			throw new Error();

		}

		// When the smallest fov spans 65% of the ellipsoid then we use the near controls
		const ellipsoidRadius = Math.max( ...ellipsoid.radius );
		const fovHoriz = 2 * Math.atan( Math.tan( MathUtils.DEG2RAD * camera.fov * 0.5 ) * camera.aspect );
		const distVert = ellipsoidRadius / Math.tan( MathUtils.DEG2RAD * camera.fov * 0.5 );
		const distHoriz = ellipsoidRadius / Math.tan( fovHoriz * 0.5 );
		const dist = Math.max( distVert, distHoriz );

		return dist;

	}

	// returns the max distance the perspective camera can move to based on globe size and fov
	_getMaxPerspectiveDistance() {

		const { camera, ellipsoid } = this;
		if ( ! camera.isPerspectiveCamera ) {

			throw new Error();

		}

		// allow for zooming out such that the ellipsoid is half the size of the largest fov
		const ellipsoidRadius = Math.max( ...ellipsoid.radius );
		const fovHoriz = 2 * Math.atan( Math.tan( MathUtils.DEG2RAD * camera.fov * 0.5 ) * camera.aspect );
		const distVert = ellipsoidRadius / Math.tan( MathUtils.DEG2RAD * camera.fov * 0.5 );
		const distHoriz = ellipsoidRadius / Math.tan( fovHoriz * 0.5 );
		const dist = 2 * Math.max( distVert, distHoriz );

		return dist;

	}

	// returns the transition threshold for orthographic zoom based on the globe size and camera settings
	_getOrthographicTransitionZoom() {

		const { camera, ellipsoid } = this;
		if ( ! camera.isOrthographicCamera ) {

			throw new Error();

		}

		const orthoHeight = ( camera.top - camera.bottom );
		const orthoWidth = ( camera.right - camera.left );
		const orthoSize = Math.max( orthoHeight, orthoWidth );
		const ellipsoidRadius = Math.max( ...ellipsoid.radius );
		const ellipsoidDiameter = 2 * ellipsoidRadius;
		return 2 * orthoSize / ellipsoidDiameter;

	}

	// returns the minimum allowed orthographic zoom based on the globe size and camera settings
	_getMinOrthographicZoom() {

		const { camera, ellipsoid } = this;
		if ( ! camera.isOrthographicCamera ) {

			throw new Error();

		}

		const orthoHeight = ( camera.top - camera.bottom );
		const orthoWidth = ( camera.right - camera.left );
		const orthoSize = Math.min( orthoHeight, orthoWidth );
		const ellipsoidRadius = Math.max( ...ellipsoid.radius );
		const ellipsoidDiameter = 2 * ellipsoidRadius;
		return 0.7 * orthoSize / ellipsoidDiameter;

	}

	// returns the "virtual position" of the orthographic based on where it is and
	// where it's looking primarily so we can reasonably position the camera object
	// in space and derive a reasonable "up" value.
	_getVirtualOrthoCameraPosition( target, camera = this.camera ) {

		const { ellipsoidFrame, ellipsoidFrameInverse, ellipsoid } = this;
		if ( ! camera.isOrthographicCamera ) {

			throw new Error();

		}

		// get ray in globe coordinate frame
		_ray.origin.copy( camera.position );
		_ray.direction.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
		_ray.applyMatrix4( ellipsoidFrameInverse );

		// get the closest point to the ray on the globe in the global coordinate frame
		ellipsoid
			.closestPointToRayEstimate( _ray, _pos )
			.applyMatrix4( ellipsoidFrame );

		// get ortho camera info
		const orthoHeight = ( camera.top - camera.bottom );
		const orthoWidth = ( camera.right - camera.left );
		const orthoSize = Math.max( orthoHeight, orthoWidth ) / camera.zoom;
		_forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );

		// ensure we move the camera exactly along the forward vector to avoid shifting
		// the camera in other directions due to floating point error
		const dist = _pos.sub( camera.position ).dot( _forward );
		target.copy( camera.position ).addScaledVector( _forward, dist - orthoSize * 4 );

	}

	_isNearControls() {

		const { camera } = this;
		if ( camera.isPerspectiveCamera ) {

			return this.getDistanceToCenter() < this._getPerspectiveTransitionDistance();

		} else {

			return camera.zoom > this._getOrthographicTransitionZoom();

		}

	}

	_raycast( raycaster ) {

		const result = super._raycast( raycaster );
		if ( result === null ) {

			// if there was no hit then fallback to intersecting the ellipsoid.
			const { ellipsoid, ellipsoidFrame, ellipsoidFrameInverse } = this;
			_ray.copy( raycaster.ray ).applyMatrix4( ellipsoidFrameInverse );

			const point = ellipsoid.intersectRay( _ray, _vec );
			if ( point !== null ) {

				point.applyMatrix4( ellipsoidFrame );
				return {
					point: point.clone(),
					distance: point.distanceTo( raycaster.ray.origin ),
				};

			} else {

				return null;

			}


		} else {

			return result;

		}

	}

}
